You're out of free questions.

Upgrade now

Writing programming interview questions hasn't made me rich yet ... so I might give up and start trading Apple stocks all day instead.

First, I wanna know how much money I could have made yesterday if I'd been trading Apple stocks all day.

So I grabbed Apple's stock prices from yesterday and put them in a list called stock_prices, where:

  • The indices are the time (in minutes) past trade opening time, which was 9:30am local time.
  • The values are the price (in US dollars) of one share of Apple stock at that time.

So if the stock cost $500 at 10:30am, that means stock_prices[60] = 500.

Write an efficient function that takes stock_prices and returns the best profit I could have made from one purchase and one sale of one share of Apple stock yesterday.

For example:

  stock_prices = [10, 7, 5, 8, 11, 9]

get_max_profit(stock_prices)
# Returns 6 (buying for $5 and selling for $11)

No "shorting"—you need to buy before you can sell. Also, you can't buy and sell in the same time step—at least 1 minute has to pass.

Gotchas

You can't just take the difference between the highest price and the lowest price, because the highest price might come before the lowest price. And you have to buy before you can sell.

What if the price goes down all day? In that case, the best profit will be negative.

You can do this in O(n)O(n) time and O(1)O(1) space!

Not sure what this is? It’s big O notation—a tool we use for talking about how much time an algorithm takes to run or how much memory a data structure takes up in our computer.

It’s pretty simple. Learn about big O notation

Breakdown

To start, try writing an example value for stock_prices and finding the maximum profit "by hand." What's your process for figuring out the maximum profit?

The brute force

A brute force algorithm finds a solution by trying all possible answers and picking the best one.

Say you're a cashier and need to give someone 67 cents (US) using as few coins as possible. How would you do it?

You could try running through all potential coin combinations and pick the one that adds to 67 cents using the fewest coins. That's a brute force algorithm, since you're trying all possible ways to make change.

Here are a few other brute force algorithms:

  • Trying to fit as many overlapping meetings as possible in a conference room? Run through all possible schedules, and pick the schedule that fits the most meetings in the room.
  • Trying to find the cheapest route through a set of cities? Try all possible routes and pick the cheapest one.
  • Looking for a minimum spanning tree in a graph? Try all possible sets of edges, and pick the cheapest set that's also a tree.

Brute force solutions are usually very slow since they involve testing a huge number of possible answers.

Brute force approaches are rarely the most efficient. Other approaches, like greedy algorithms or dynamic programming tend to be faster.

Even so, talking through a brute force solution can be a good first step in a coding interview. It's usually pretty easy to derive, so it allows you to quickly make progress and come up with something that works. From there, you have some helpful boundaries for refining your algorithm—you're only interested in solutions that are faster (and/or more space efficient) than the brute force solution you've already come up with.

approach would be to try every pair of times (treating the earlier time as the buy time and the later time as the sell time) and see which one is higher.

  def get_max_profit(stock_prices):
    max_profit = 0

    # Go through every time
    for outer_time in range(len(stock_prices)):

        # For every time, go through every other time
        for inner_time in range(len(stock_prices)):
            # For each pair, find the earlier and later times
            earlier_time = min(outer_time, inner_time)
            later_time   = max(outer_time, inner_time)

            # And use those to find the earlier and later prices
            earlier_price = stock_prices[earlier_time]
            later_price   = stock_prices[later_time]

            # See what our profit would be if we bought at the
            # earlier price and sold at the later price
            potential_profit = later_price - earlier_price

            # Update max_profit if we can do better
            max_profit = max(max_profit, potential_profit)

    return max_profit

But that will take O(n2)O(n^2) time,

Not sure what this is? It’s big O notation—a tool we use for talking about how much time an algorithm takes to run or how much memory a data structure takes up in our computer.

It’s pretty simple. Learn about big O notation

since we have two nested loops—for every time, we're going through every other time. Also, it's not correct: we won't ever report a negative profit! Can we do better?

Well, we’re doing a lot of extra work. We’re looking at every pair twice. We know we have to buy before we sell, so in our inner for loop we could just look at every price after the price in our outer for loop.

That could look like this:

  def get_max_profit(stock_prices):
    max_profit = 0

    # Go through every price (with its index as the time)
    for earlier_time, earlier_price in enumerate(stock_prices):

        # And go through all the LATER prices
        for later_time in range(earlier_time + 1, len(stock_prices)):
            later_price = stock_prices[later_time]

            # See what our profit would be if we bought at the
            # earlier price and sold at the later price
            potential_profit = later_price - earlier_price

            # Update max_profit if we can do better
            max_profit = max(max_profit, potential_profit)

    return max_profit

What’s our runtime now?

Well, our outer for loop goes through all the times and prices, but our inner for loop goes through one fewer price each time. So our total number of steps is the sum n+(n1)+(n2)...+2+1n + (n - 1) + (n - 2) ... + 2 + 1

The sum of integers 1..n1..n is n22\approx \frac{n^2}{2}, which is O(n2)O(n^2)

Series like this actually come up quite a bit:

1+2+3+...+(n1)+n 1 + 2 + 3 + ... + (n-1) + n

Or, equivalently, the other way around:

n+(n1)+...+3+2+1 n + (n-1) + ... + 3 + 2 + 1

And sometimes the last nn is omitted, but as we'll see it doesn't affect the big O:

(n1)+(n2)+...+3+2+1 (n-1) + (n-2) + ... + 3 + 2 + 1

Let's draw this out. Let's say n=10n=10, so we'll represent n1n-1 as nine circles:

A row of 9 circles.

We can continue the pattern with n2n-2

2 rows of circles: 9 on top, 8 on the bottom.

And n3n-3, n4n-4, etc, all the way down to 1:

9 rows of circles: 9 on top, and one fewer circle in each row, down to 1 circle on the bottom.

Notice both the top and right "sides" of our set of circles have n1n-1 items:

In a right triangle formed by 9 rows of circles (9 in the top row, down to 1 in the bottom row) the top and left side of the triangle are 9 circles long.

In fact, we could imagine our circles inside of a square with sides of length n-1:

A square around 9 rows of circles (9 in the top row, down to 1 in the bottom row) so the square is 9 by 9 circles.

Notice that we've filled in just about half of the square!

Of course, the area of the square is (n1)(n1)(n-1) * (n-1), which is O(n2)O(n^2). Our total number of circles is about half of that, so O(n2/2)O(n^2/2), which is still O(n2)O(n^2). Remember: with big O notation, we throw out the constants.

If we had started from nn instead of n1n-1 we'd have O(n2+n)O(n^2 + n), which again is still O(n2)O(n^2) since in big O notation we drop the less significant terms.

, which is still O(n2)O(n^2) time.

We can do better!

If we're going to do better than O(n2)O(n^2), we're probably going to do it in either O(nlgn)O(n\lg{n}) or O(n)O(n). O(nlgn)O(n\lg{n}) comes up in sorting and searching algorithms where we're recursively cutting the list in half. It's not obvious that we can save time by cutting the list in half here. Let's first see how well we can do by looping through the list only once.

Since we're trying to loop through the list once, let's use a greedy

A greedy algorithm builds up a solution by choosing the option that looks the best at every step.

Say you're a cashier and need to give someone 67 cents (US) using as few coins as possible. How would you do it?

Whenever picking which coin to use, you'd take the highest-value coin you could. A quarter, another quarter, then a dime, a nickel, and finally two pennies. That's a greedy algorithm, because you're always greedily choosing the coin that covers the biggest portion of the remaining amount.

Some other places where a greedy algorithm gets you the best solution:

  • Trying to fit as many overlapping meetings as possible in a conference room? At each step, schedule the meeting that ends earliest.
  • Looking for a minimum spanning tree in a graph? At each step, greedily pick the cheapest edge that reaches a new vertex.

Careful: sometimes a greedy algorithm doesn't give you an optimal solution:

Validating that a greedy strategy always gets the best answer is tricky. Either prove that the answer produced by the greedy algorithm is as good as an optimal answer, or run through a rigorous set of test cases to convince your interviewer (and yourself) that its correct.

approach, where we keep a running max_profit until we reach the end. We'll start our max_profit at $0. As we're iterating, how do we know if we've found a new max_profit?

At each iteration, our max_profit is either:

  1. the same as the max_profit at the last time step, or
  2. the max profit we can get by selling at the current_price

How do we know when we have case (2)?

The max profit we can get by selling at the current_price is simply the difference between the current_price and the min_price from earlier in the day. If this difference is greater than the current max_profit, we have a new max_profit.

So for every price, we’ll need to:

  • keep track of the lowest price we’ve seen so far
  • see if we can get a better profit

Here’s one possible solution:

  def get_max_profit(stock_prices):
    min_price  = stock_prices[0]
    max_profit = 0

    for current_price in stock_prices:
        # Ensure min_price is the lowest price we've seen so far
        min_price = min(min_price, current_price)

        # See what our profit would be if we bought at the
        # min price and sold at the current price
        potential_profit = current_price - min_price

        # Update max_profit if we can do better
        max_profit = max(max_profit, potential_profit)

    return max_profit

We’re finding the max profit with one pass and constant space!

Are we done? Let’s think about some edge cases. What if the price stays the same? What if the price goes down all day?

If the price doesn't change, the max possible profit is 0. Our function will correctly return that. So we're good.

But if the value goes down all day, we’re in trouble. Our function would return 0, but there’s no way we could break even if the price always goes down.

How can we handle this?

Well, what are our options? Leaving our function as it is and just returning zero is not a reasonable option—we wouldn't know if our best profit was negative or actually zero, so we'd be losing information. Two reasonable options could be:

  1. return a negative profit. “What’s the least badly we could have done?”
  2. raise an exception. “We should not have purchased stocks yesterday!”

In this case, it’s probably best to go with option (1). The advantages of returning a negative profit are:

  • We more accurately answer the challenge. If profit is "revenue minus expenses", we’re returning the best we could have done.
  • It's less opinionated. We'll leave decisions up to our function’s users. It would be easy to wrap our function in a helper function to decide if it’s worth making a purchase.
  • We allow ourselves to collect better data. It matters if we would have lost money, and it matters how much we would have lost. If we’re trying to get rich, we’ll probably care about those numbers.

How can we adjust our function to return a negative profit if we can only lose money? Initializing max_profit to 0 won’t work...

Well, we started our min_price at the first price, so let’s start our max_profit at the first profit we could get—if we buy at the first time and sell at the second time.

  min_price  = stock_prices[0]
max_profit = stock_prices[1] - stock_prices[0]

But we have the potential for an index out of bounds error here, if stock_prices has fewer than 2 prices.

We do want to raise an exception in that case, since profit requires buying and selling, which we can't do with less than 2 prices. So, let's explicitly check for this case and handle it:

  if len(stock_prices) < 2:
    raise ValueError('Getting a profit requires at least 2 prices')

min_price  = stock_prices[0]
max_profit = stock_prices[1] - stock_prices[0]

Ok, does that work?

No! max_profit is still always 0. What’s happening?

If the price always goes down, min_price is always set to the current_price. So current_price - min_price comes out to 0, which of course will always be greater than a negative profit.

When we’re calculating the max_profit, we need to make sure we never have a case where we try both buying and selling stocks at the current_price.

To make sure we’re always buying at an earlier price, never the current_price, let’s switch the order around so we calculate max_profit before we update min_price.

We'll also need to pay special attention to time 0. Make sure we don't try to buy and sell at time 0.

Solution

We’ll greedily

A greedy algorithm builds up a solution by choosing the option that looks the best at every step.

Say you're a cashier and need to give someone 67 cents (US) using as few coins as possible. How would you do it?

Whenever picking which coin to use, you'd take the highest-value coin you could. A quarter, another quarter, then a dime, a nickel, and finally two pennies. That's a greedy algorithm, because you're always greedily choosing the coin that covers the biggest portion of the remaining amount.

Some other places where a greedy algorithm gets you the best solution:

  • Trying to fit as many overlapping meetings as possible in a conference room? At each step, schedule the meeting that ends earliest.
  • Looking for a minimum spanning tree in a graph? At each step, greedily pick the cheapest edge that reaches a new vertex.

Careful: sometimes a greedy algorithm doesn't give you an optimal solution:

Validating that a greedy strategy always gets the best answer is tricky. Either prove that the answer produced by the greedy algorithm is as good as an optimal answer, or run through a rigorous set of test cases to convince your interviewer (and yourself) that its correct.

walk through the list to track the max profit and lowest price so far.

For every price, we check if:

  • we can get a better profit by buying at min_price and selling at the current_price
  • we have a new min_price

To start, we initialize:

  1. min_price as the first price of the day
  2. max_profit as the first profit we could get

We decided to return a negative profit if the price decreases all day and we can't make any money. We could have raised an exception instead, but returning the negative profit is cleaner, makes our function less opinionated, and ensures we don't lose information.

  def get_max_profit(stock_prices):
    if len(stock_prices) < 2:
        raise ValueError('Getting a profit requires at least 2 prices')

    # We'll greedily update min_price and max_profit, so we initialize
    # them to the first price and the first possible profit
    min_price  = stock_prices[0]
    max_profit = stock_prices[1] - stock_prices[0]

    # Start at the second (index 1) time
    # We can't sell at the first time, since we must buy first,
    # and we can't buy and sell at the same time!
    # If we started at index 0, we'd try to buy *and* sell at time 0.
    # This would give a profit of 0, which is a problem if our
    # max_profit is supposed to be *negative*--we'd return 0.
    for current_time in range(1, len(stock_prices)):
        current_price = stock_prices[current_time]

        # See what our profit would be if we bought at the
        # min price and sold at the current price
        potential_profit = current_price - min_price

        # Update max_profit if we can do better
        max_profit = max(max_profit, potential_profit)

        # Update min_price so it's always
        # the lowest price we've seen so far
        min_price  = min(min_price, current_price)

    return max_profit

Complexity

O(n)O(n) time and O(1)O(1) space.

Not sure what this is? It’s big O notation—a tool we use for talking about how much time an algorithm takes to run or how much memory a data structure takes up in our computer.

It’s pretty simple. Learn about big O notation

We only loop through the list once.

What We Learned

This one's a good example of the greedy

A greedy algorithm builds up a solution by choosing the option that looks the best at every step.

Say you're a cashier and need to give someone 67 cents (US) using as few coins as possible. How would you do it?

Whenever picking which coin to use, you'd take the highest-value coin you could. A quarter, another quarter, then a dime, a nickel, and finally two pennies. That's a greedy algorithm, because you're always greedily choosing the coin that covers the biggest portion of the remaining amount.

Some other places where a greedy algorithm gets you the best solution:

  • Trying to fit as many overlapping meetings as possible in a conference room? At each step, schedule the meeting that ends earliest.
  • Looking for a minimum spanning tree in a graph? At each step, greedily pick the cheapest edge that reaches a new vertex.

Careful: sometimes a greedy algorithm doesn't give you an optimal solution:

Validating that a greedy strategy always gets the best answer is tricky. Either prove that the answer produced by the greedy algorithm is as good as an optimal answer, or run through a rigorous set of test cases to convince your interviewer (and yourself) that its correct.

approach in action. Greedy approaches are great because they're fast (usually just one pass through the input). But they don't work for every problem.

How do you know if a problem will lend itself to a greedy approach? Best bet is to try it out and see if it works. Trying out a greedy approach should be one of the first ways you try to break down a new question.

To try it on a new problem, start by asking yourself:

"Suppose we could come up with the answer in one pass through the input, by simply updating the 'best answer so far' as we went. What additional values would we need to keep updated as we looked at each item in our input, in order to be able to update the 'best answer so far' in constant time?"

In this case:

The "best answer so far" is, of course, the max profit that we can get based on the prices we've seen so far.

The "additional value" is the minimum price we've seen so far. If we keep that updated, we can use it to calculate the new max profit so far in constant time. The max profit is the larger of:

  1. The previous max profit
  2. The max profit we can get by selling now (the current price minus the minimum price seen so far)

Try applying this greedy methodology to future questions.

Do you have an answer?

Wanna review this one again later? Or do you feel like you got it all?

Mark as done Pin for review later
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
def get_max_profit(stock_prices):
# Calculate the max profit
return 0
# Tests
import unittest
class Test(unittest.TestCase):
def test_price_goes_up_then_down(self):
actual = get_max_profit([1, 5, 3, 2])
expected = 4
self.assertEqual(actual, expected)
def test_price_goes_down_then_up(self):
actual = get_max_profit([7, 2, 8, 9])
expected = 7
self.assertEqual(actual, expected)
def test_big_increase_then_small_increase(self):
actual = get_max_profit([2, 10, 1, 4])
expected = 8
self.assertEqual(actual, expected)
def test_price_goes_up_all_day(self):
actual = get_max_profit([1, 6, 7, 9])
expected = 8
self.assertEqual(actual, expected)
def test_price_goes_down_all_day(self):
actual = get_max_profit([9, 7, 4, 1])
expected = -2
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

Reset editor

Powered by qualified.io

. . .